﻿using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace RedisDotNet
{
    /// <summary>
    /// Low level redis client implementation
    /// </summary>
    public class RedisNetworkClient : IRedisNetworkClient
    {
        #region Constants

        private const string SentinelError = "-";
        private const string SentinelBulkCount = "$";
        private const string SentinelMultiBulkCount = "*";

        #endregion

        #region Fields

        private readonly IPAddress ipAddress;
        private readonly int port;
        private Socket socket;
        private NetworkStream stream;

        private static readonly Encoding dataEncoding = Encoding.UTF8;
        private static readonly Encoding commandEncoding = Encoding.ASCII;


        #endregion

        #region Constructors

        public RedisNetworkClient(IPAddress ipAddress, int port)
        {
            this.ipAddress = ipAddress;
            this.port = port;
        }

        #endregion

        #region Connection management

        /// <summary>
        /// Ensures that a connection is made
        /// </summary>
        private void EnsureConnect()
        {
            if (socket != null)
                return;
            socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            socket.NoDelay = true;
            socket.Connect(ipAddress, port);
            stream = new NetworkStream(socket);
        }

        /// <summary>
        /// Ensures that a disconnection is made
        /// </summary>
        private void EnsureDisconnect()
        {
            if (socket == null)
                return;
            if (!socket.Connected)
                return;
            socket.Disconnect(false);
        }

        #endregion 

        #region Network send/recieve helpers

        /// <summary>
        /// Sends bytes to the network
        /// </summary>
        /// <param name="bytes">The bytes.</param>
        void SendBytes(byte[] bytes)
        {
            socket.Send(bytes);
        }

        /// <summary>
        /// Sends the new line to the network (\r\n)
        /// </summary>
        private void SendNewLine()
        {
            socket.Send(new byte[] {0xd, 0xa});
        }

        /// <summary>
        /// Receives a set number of bytes from the network
        /// </summary>
        /// <param name="byteCount">The byte count.</param>
        /// <returns>The received bytes</returns>
        /// <remarks>
        /// Adapted from:
        /// http://www.yoda.arachsys.com/csharp/readbinary.html
        /// </remarks>
        private byte[] ReceiveBytes(int byteCount)
        {
            Check.IsInRange(byteCount, 0, Int32.MaxValue, "byteCount");

            var buf = new byte[byteCount];
            var offset = 0;
            var remaining = byteCount;
            while (remaining > 0)
            {
                var read = stream.Read(buf, offset, remaining);
                if (read <= 0)
                    throw new RedisClientException(Strings.ExProtocolError);
                remaining -= read;
                offset += read;
            }

            return buf;
        }

        /// <summary>
        /// Receives a line from the network
        /// </summary>
        /// <returns>The recieved bytes without termination</returns>
        private byte[] ReceiveLine()
        {
            var offset = 0;
            var buf = new byte[256];
            int read;
            while (true)
            {
                // Read one char from response
                read = stream.Read(buf, offset, 1);

                // Check for protocol errors
                if (read <= 0)
                    throw new RedisClientException(Strings.ExProtocolError);

                // Check for end of line start \r
                if (buf[offset] == 0xd)
                    break;

                // Increment offset counter
                offset += 1;

                // Carry on reading if buffer length not exceeded
                if (offset <= buf.Length)
                    continue;

                // resize array in a polynomial fashion
                var newBuf = new byte[buf.Length * 2];
                Array.Copy(buf, newBuf, buf.Length);
                buf = newBuf;
            }

            // Read, check and dispose of termination byte \n
            var terminator = new byte[1];
            read = stream.Read(terminator, 0, 1);
            if (read <= 0)
                throw new RedisClientException(Strings.ExProtocolError);
            if (terminator[0] != 0xa)
                throw new RedisClientException(Strings.ExProtocolError);

            // Make final copy buffer of right size
            var outBuf = new byte[offset];
            Array.Copy(buf, outBuf, offset);

            return outBuf;
        }

        /// <summary>
        /// Expect a the bulk count from the redis server
        /// </summary>
        /// <returns>Bulk item count</returns>
        private int ExpectBulkCount()
        {
            // Read line
            var reply = commandEncoding.GetString(ReceiveLine());

            // Check sentinel
            if (!reply.StartsWith(SentinelBulkCount))
                throw new RedisClientException(Strings.ExExpectedBulkReply);

            // Convert and return remainder of reply
            return Convert.ToInt32(reply.Substring(1));
        }

        /// <summary>
        /// Expect a multi bulk reply from the server.
        /// </summary>
        /// <returns>Multi bulk item count</returns>
        private int ExpectMultiBulkCount()
        {
            // Read line
            var reply = commandEncoding.GetString(ReceiveLine());

            // Check sentinel
            if (!reply.StartsWith(SentinelMultiBulkCount))
                throw new RedisClientException(Strings.ExExpectedMultiBulkReply);

            // Convert and return remainder of reply
            return Convert.ToInt32(reply.Substring(1));
        }

        #endregion

        #region Error handling

        /// <summary>
        /// Check for server errors in response
        /// </summary>
        /// <param name="reply">Server reply</param>
        private static void CheckServerError(string reply)
        {
            Check.IsNotNull(reply, "reply");

            // If not an error, return quickly
            if (!reply.StartsWith(SentinelError))
                return;

            // Extract server message
            var message = reply.Substring(1);

            // Raise error exception
            throw new RedisClientException(
                string.Format(Strings.ExServerError, message));
        }

        #endregion

        #region IRedisNetworkClient Members

        #region Issue commands

        /// <summary>
        /// Issues a simple command to the redis server
        /// </summary>
        /// <param name="command">The command.</param>
        /// <param name="parameters">The parameters.</param>
        public void IssueSimpleCommand(string command, params string[] parameters)
        {
            Check.IsNotNullOrEmpty(command, "command");
            foreach (var parameter in parameters)
                Check.IsNotNullOrEmpty(parameter, "parameters");

            EnsureConnect();

            var cmd = string.Format("{0} {1}", command, string.Join(" ", parameters));
            var buf = commandEncoding.GetBytes(cmd);
            SendBytes(buf);
            SendNewLine();
        }

        /// <summary>
        /// Issues the bulk command to the redis server with string data
        /// </summary>
        /// <param name="command">The command.</param>
        /// <param name="key">The key.</param>
        /// <param name="data">The data.</param>
        public void IssueBulkCommand(string command, string key, string data)
        {
            IssueBulkCommand(command, key, dataEncoding.GetBytes(data));
        }

        /// <summary>
        /// Issues the bulk command to the redis server with binary data
        /// </summary>
        /// <param name="command">The command.</param>
        /// <param name="key">The key.</param>
        /// <param name="data">The data.</param>
        public void IssueBulkCommand(string command, string key, byte[] data)
        {
            Check.IsNotNullOrEmpty(command, "command");
            Check.IsNotNullOrEmpty(key, "key");
            Check.IsNotNull(data, "data");

            EnsureConnect();

            var bulkCommand = string.Format("{0} {1} {2}", command, key, data.Length);
            SendBytes(commandEncoding.GetBytes(bulkCommand));
            SendNewLine();
            SendBytes(data);
            SendNewLine();
        }

        #endregion

        #region Expect replies

        /// <summary>
        /// Waits for an integer reply from the server
        /// </summary>
        /// <returns>Integer reply value</returns>
        public int ExpectIntegerReply()
        {
            var reply = commandEncoding.GetString(ReceiveLine());

            CheckServerError(reply);

            if (!reply.StartsWith(":"))
                throw new Exception(Strings.ExExpectedIntegerReply);
            return Convert.ToInt32(reply.Substring(1));
        }

        /// <summary>
        /// Waits for a single line reply from the server
        /// </summary>
        /// <returns>Single line reply value</returns>
        public string ExpectSingleLineReply()
        {
            var reply = commandEncoding.GetString(ReceiveLine());

            CheckServerError(reply);

            if (!reply.StartsWith("+"))
                throw new Exception(Strings.ExExpectedSingleLineReply);
            
            return reply.Substring(1);
        }

        /// <summary>
        /// Waits for a bulk reply from the server as binary.
        /// </summary>
        /// <returns></returns>
        public byte[] ExpectBulkReplyBinary()
        {
            // Get bulk count
            var byteCount = ExpectBulkCount();

            // Check element found
            if (byteCount < 0)
                return null;

            // Read bytes
            var bytes = ReceiveBytes(byteCount);

            // Read trailing line break \r\n
            ReceiveBytes(2);

            // Convert and return
            return bytes;
        }

        /// <summary>
        /// Waits for a bulk reply from the server as a string.
        /// </summary>
        /// <returns></returns>
        public string ExpectBulkReply()
        {
            var result = ExpectBulkReplyBinary();
            if (result == null)
                return null;
            return dataEncoding.GetString(result);
        }

        /// <summary>
        /// Waits for a multi bulk reply from the server.
        /// </summary>
        /// <returns>Multi bulk string array.</returns>
        public string[] ExpectMultiBulkReply()
        {
            // Get entire result
            var result = ExpectMultiBulkReplyBinary();

            // Handle nulls
            if (result == null)
                return null;

            // Create array of strings long enough
            var retVal = new string[result.Length];

            // Loop all elements in results
            var ix = 0;
            foreach (var element in result)
                // Translate to string
                retVal[ix++] = dataEncoding.GetString(element);

            // Done
            return retVal;
        }

        /// <summary>
        /// Waits for a multi bulk reply from the server in binary.
        /// </summary>
        /// <returns>Multi bulk byte/string array.</returns>
        public byte[][] ExpectMultiBulkReplyBinary()
        {
            // Get total result count
            var resultCount = ExpectMultiBulkCount();

            // Create byte array of arrays big enough
            var retVal = new byte[resultCount][];

            // Loop and load each bulk reply into the array
            for (int i = 0; i < resultCount; i++)
                retVal[i] = ExpectBulkReplyBinary();

            // Done
            return retVal;
        }

        #endregion

        #region end point information

        /// <summary>
        /// Gets the port.
        /// </summary>
        /// <value>The port.</value>
        public int Port
        {
            get { return port; }
        }

        /// <summary>
        /// Gets the ip address.
        /// </summary>
        /// <value>The ip address.</value>
        public string IpAddress
        {
            get { return ipAddress.ToString(); }
        }

        #endregion

        #endregion

        #region IDisposable Members

        /// <summary>
        /// Performs application-defined tasks associated with freeing, 
        /// releasing, or resetting unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            EnsureDisconnect();
        }

        #endregion
    }
}
